Skip to content

feat(server)!: refuse an undeclared creative start policy on control_media_buy - #1872

Merged
bokelley merged 1 commit into
nextfrom
feat/1869-control-media-buy-creative-start-policy
Nov 8, 2026
Merged

bokelley merged 1 commit into
nextfrom
feat/1869-control-media-buy-creative-start-policy

Conversation

@bokelley

@bokelley bokelley commented Nov 8, 2026 •

Copy link
Copy Markdown
Contributor

Summary

AdCP 3.3 lets a buyer hold a package's first enablement for serving until every assigned creative is approved: packages[].start_requires_all_creatives_approved on control_media_buy. A seller that does not declare media_buy.features.creative_start_policy: true MUST reject true with UNSUPPORTED_FEATURE before mutation. false is the default and always allowed.

Before this change the SDK let true through. A seller that didn't know the field ignored it, and the buy started serving before its creatives were approved, which is the opposite of what the buyer asked for. The dispatcher now enforces the rule from the seller's own declared capabilities, the way it enforces the other 3.3 seller-side rules (#1668).

Re-scoped per the comment on #1869: AdCP 3.3 withdraws the field from create_media_buy / update_media_buy packages (adcontextprotocol/adcp#8767), so the check is on control_media_buy only.

Behaviour

create_tool_caller (the dispatch every MCP and A2A tool call goes through) runs the check for control_media_buy after schema and model validation and before the handler runs, when the handler is on AdCP 3.3 or later (the same version gate as changed_paths):

  • No package sets true: nothing happens, and capabilities are not read.
  • A package sets true and the seller declares the feature: the request reaches the handler unchanged.
  • Otherwise the request fails:
{
  "code": "UNSUPPORTED_FEATURE",
  "message": "start_requires_all_creatives_approved: true requires media_buy.features.creative_start_policy, which this seller does not declare",
  "field": "packages[0].start_requires_all_creatives_approved",
  "suggestion": "Omit start_requires_all_creatives_approved or send false; check media_buy.features.creative_start_policy in get_adcp_capabilities before opting in",
  "recovery": "correctable",
  "details": {"feature": "media_buy.features.creative_start_policy", "fields": ["packages[0].start_requires_all_creatives_approved"]},
  "mutation_outcome": "not_applied"
}

field names the first opted-in package and details.fields lists every one, so the rejection is atomic and the buyer can fix them all at once. mutation_outcome: not_applied comes from the existing pre-dispatch marking.

Where the declaration comes from

A new ADCPHandler._declared_capabilities_for_request(params, context) hook supplies it:

  • PlatformHandler returns its request-scoped DecisioningCapabilities, so a tenant-specific declaration from get_adcp_capabilities_for_request counts. This is the same resolution refine_proposals and push-config admission already use.
  • Any other ADCPHandler (including the decorator builder) returns its own get_adcp_capabilities response. A handler that doesn't implement discovery declares nothing, so true is refused.

Not gated

  • create_media_buy / update_media_buy: 3.3 removes the field from their packages, and their open schemas ignore it (DR-0009). There's a test pinning this.
  • Handlers on 3.2: the field doesn't exist in that release. A 3.3 buyer sending true to a 3.2 handler is already refused by request conversion (VERSION_UNSUPPORTED).

Repro (#1869, adapted to control_media_buy)

class Seller(ADCPHandler[Any]):
    async def control_media_buy(self, params, context=None):
        seen.append(params)
        ...

await create_tool_caller(Seller(), "control_media_buy")({
    "adcp_version": "3.3-beta.5", "idempotency_key": "a" * 16,
    "account": {"account_id": "acct_1"}, "media_buy_id": "mb_1", "revision": 1,
    "packages": [{"package_id": "pkg_1", "start_requires_all_creatives_approved": True}],
})

Before: handler called: True. After: ADCPTaskError with the error above, and handler called: False.

TypeScript SDK parity

@adcp/sdk (adcontextprotocol/adcp-client main at b6e5caf8d) doesn't enforce this. Its server code never reads features.creative_start_policy. The only related checks are the boolean-shape and 3.3-version checks on the buyer-side update_creative_start_policy action assessment (src/lib/media-buy/action-assessment.ts). So there was no TS error shape to copy, and the shape above follows the spec (UNSUPPORTED_FEATURE, correctable, JSONPath-lite field). A matching TS issue should be filed.

Other changes

  • _requires_changed_paths is renamed _handler_runs_adcp_33 and shared by both 3.3 seller rules.
  • DecisioningPlatform.get_adcp_capabilities_for_request documents the new call site.
  • Docs: docs/adcp-3.3-sdk-inventory.md (beta.1 delta row), docs/decisioning-capabilities.md (media_buy.features.creative_start_policy) and a MIGRATION_v9_to_v10.md row.

Test plan

  • tests/test_control_media_buy_creative_start_policy.py (16 tests):
    • the issue repro is refused;
    • true is refused with no discovery, with no feature, and with the feature set to false;
    • true passes when the seller declares the feature;
    • false and omission always pass without reading capabilities;
    • field / details.fields are correct across several packages;
    • a 3.2 handler is not gated, and neither is update_media_buy;
    • the refusal reaches a real ADCPClient over MCP with mutation_outcome: not_applied;
    • PlatformHandler tests cover no feature, false, declared, and tenant-scoped.
  • Full suite: 21912 passed, 2965 skipped, 1 xfailed (-n 8; test_import_memory.py run serially: 6 passed)
  • make typecheck-all
  • make lint
  • tests/test_import_memory.py
  • Reporting vectors unchanged

BREAKING CHANGE: control_media_buy refuses packages[].start_requires_all_creatives_approved: true with UNSUPPORTED_FEATURE before the handler runs when the seller's capabilities do not declare media_buy.features.creative_start_policy: true. A seller that honors the gate declares the feature.

Closes #1869

🤖 Generated with Claude Code

…media_buy

A control_media_buy package that sets start_requires_all_creatives_approved:
true now reaches the handler only when the seller declares
media_buy.features.creative_start_policy: true. Otherwise the dispatcher
answers UNSUPPORTED_FEATURE before the handler runs (recovery correctable,
field packages[N].start_requires_all_creatives_approved for the first opted-in
package, details.fields listing every one, mutation_outcome not_applied), as
AdCP 3.3 requires. false and omission are always allowed and read no
capabilities.

The declaration is the seller's own: PlatformHandler reads its request-scoped
DecisioningCapabilities (get_adcp_capabilities_for_request included), and any
other ADCPHandler reads its own get_adcp_capabilities response, through the new
ADCPHandler._declared_capabilities_for_request hook. The check applies to
handlers on AdCP 3.3 or later, like changed_paths enforcement.

create_media_buy and update_media_buy are not checked: AdCP 3.3 withdraws the
field from their packages (adcontextprotocol/adcp#8767) and their open schemas
ignore it (DR-0009).

BREAKING CHANGE: control_media_buy refuses
packages[].start_requires_all_creatives_approved: true with UNSUPPORTED_FEATURE
before the handler runs when the seller's capabilities do not declare
media_buy.features.creative_start_policy: true. A seller that honors the gate
declares the feature.

Closes #1869

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@aao-secretariat aao-secretariat Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ladon verdict: Approve

Approve — clean PR, no blocking or medium findings.

This PR adds a dispatcher-level gate: control_media_buy requests with packages[].start_requires_all_creatives_approved: true are refused (UNSUPPORTED_FEATURE, recovery: correctable, mutation_outcome: not_applied) before the handler runs unless the seller declares media_buy.features.creative_start_policy, on handlers at 3.3+.

Verified against repo-specific gates:

  • Semver signal present: feat(server)!: prefix plus migration note; no public export removed or type-changed.
  • Fail-closed behavior: no discovery / no feature / feature=false all refuse; _not_supported reads as not-declared rather than raising.
  • Version-translation pathway (DR-0032/0033): 3.3 buyer → 3.2 handler refused by conversion (VERSION_UNSUPPORTED); genuine 3.2 seller ignores per open schema (DR-0009). This is the "Refused" pathway and is covered by tests.
  • mutation_outcome: not_applied stamped pre-claim-insert by the existing mark_not_applied; test assertion holds.
  • Capabilities read only when a package opts in (discovery_calls==0 otherwise).
  • Rename _requires_changed_paths→_handler_runs_adcp_33 consistent across def/call/tests.
  • Largest-file (test, 336 new lines) control flow verified: base, decisioning, request-scoped, and roundtrip paths covered.

Decision table: no critical/high findings (row 1 n/a); gated_paths false (row 2 n/a); no deletions in high-risk reasons (row 3 n/a); no medium findings in data-loss/schema/infra (row 4 n/a); no medium findings at all so rows 5/8 n/a; no prior escalation (row 6 n/a); no no-auto-approve team match (row 7 n/a). high_risk is true only from modified/added files with no medium-or-higher concerns — not an escalation trigger on the flag alone. Falls through to row 9: approve.

@bokelley
bokelley merged commit 7baf947 into next Nov 8, 2026
41 checks passed
@bokelley
bokelley deleted the feat/1869-control-media-buy-creative-start-policy branch November 8, 2026 15:08
bokelley added a commit that referenced this pull request Nov 8, 2026
… seller known not to declare it (#1878)

AdCP 3.3 forbids a buyer from sending packages[].start_requires_all_creatives_approved:
true on control_media_buy to a seller without
media_buy.features.creative_start_policy: true. On a session negotiated at 3.3
or later, the client now checks its cached get_adcp_capabilities response (the
one version negotiation already uses) and, when the seller does not declare
the feature, returns a failed TaskResult before anything is sent:
dispatched=False, adcp_error.code UNSUPPORTED_FEATURE, adcp_error.field
packages[N].start_requires_all_creatives_approved for the first opted-in
package, and adcp_error.details with feature and every opted-in field, the
vocabulary the seller's own refusal uses (#1872).

A client that has not discovered the seller (a pinned server_version) sends
the request and lets the seller decide. false and omission always pass, and a
session below 3.3 behaves as before: version conversion refuses true there.

ADCPFeatureUnsupportedError gains optional field and details, which the
pre-send failed result carries. The shared helpers move from
adcp.server._creative_start_policy to adcp._creative_start_policy so the client
does not import the server package.

Closes #1876

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant